/**
* Licensed to Apereo under one or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information regarding copyright ownership. Apereo
* licenses this file to you under the Apache License, Version 2.0 (the "License"); you may not use
* this file except in compliance with the License. You may obtain a copy of the License at the
* following location:
*
* <p>http://www.apache.org/licenses/LICENSE-2.0
*
* <p>Unless required by applicable law or agreed to in writing, software distributed under the
* License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jasig.portlet.emailpreview.dao.javamail;
import com.sun.mail.imap.IMAPFolder;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import javax.annotation.PostConstruct;
import javax.mail.Address;
import javax.mail.AuthenticationFailedException;
import javax.mail.Authenticator;
import javax.mail.BodyPart;
import javax.mail.FetchProfile;
import javax.mail.Flags;
import javax.mail.Flags.Flag;
import javax.mail.Folder;
import javax.mail.Message;
import javax.mail.Message.RecipientType;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.Quota;
import javax.mail.Session;
import javax.mail.Store;
import javax.mail.UIDFolder;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
import javax.mail.util.SharedByteArrayInputStream;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.jasig.portlet.emailpreview.AccountSummary;
import org.jasig.portlet.emailpreview.EmailMessage;
import org.jasig.portlet.emailpreview.EmailMessageContent;
import org.jasig.portlet.emailpreview.EmailPreviewException;
import org.jasig.portlet.emailpreview.EmailQuota;
import org.jasig.portlet.emailpreview.MailStoreConfiguration;
import org.jasig.portlet.emailpreview.dao.IMailAccountDao;
import org.jasig.portlet.emailpreview.exception.MailAuthenticationException;
import org.jasig.portlet.emailpreview.service.ICredentialsProvider;
import org.jasig.portlet.emailpreview.service.link.IEmailLinkService;
import org.jasig.portlet.emailpreview.service.link.ILinkServiceRegistry;
import org.owasp.validator.html.AntiSamy;
import org.owasp.validator.html.CleanResults;
import org.owasp.validator.html.Policy;
import org.owasp.validator.html.PolicyException;
import org.owasp.validator.html.ScanException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
/**
* This class accesses the Javamail host for mailbox operations.
*
* <p>NOTE: With Javamail, uuids are not globally-unique within the user's mail store as they are
* with Exchange for instance. With Javamail the messageIds are index numbers within the mailbox
* store (which is really per-folder). Thus, many mail folders for a user may have a messageId=1.
* This does not present many problems as long as we insure we are only dealing with one folder at a
* time.
*
* @author awills
* @author James Wennmacher, jwennmacher@unicon.net
*/
@Component
public final class JavamailAccountDaoImpl implements IMailAccountDao {
private static final String CONTENT_TYPE_ATTACHMENTS_PATTERN = "multipart/mixed;";
private static final String INTERNET_ADDRESS_TYPE = "rfc822";
@Autowired(required = true)
private ILinkServiceRegistry linkServiceRegistry;
@Autowired private ICredentialsProvider credentialsProvider;
/** Value for the 'mail.debug' setting in JavaMail */
private boolean debug = false;
private String filePath = "classpath:antisamy.xml"; // default
@Autowired(required = true)
private ApplicationContext ctx;
private Policy policy;
private final Logger log = LoggerFactory.getLogger(getClass());
public void setCredentialsProvider(ICredentialsProvider credentialsProvider) {
this.credentialsProvider = credentialsProvider;
}
/**
* Set the file path to the Anti-samy policy file to be used for cleaning strings.
*
* @param path
*/
public void setSecurityFile(String path) {
this.filePath = path;
}
@PostConstruct
public void afterPropertiesSet() throws Exception {
InputStream stream = ctx.getResource(filePath).getInputStream();
policy = Policy.getInstance(stream);
}
@Override
public AccountSummary fetchAccountSummaryFromStore(
MailStoreConfiguration config,
String username,
String mailAccount,
String folder,
int start,
int max) {
Authenticator auth = credentialsProvider.getAuthenticator();
AccountSummary summary;
Folder inbox = null;
try {
// Retrieve user's folder
Session session = openMailSession(config, auth);
inbox = getUserInbox(session, folder);
inbox.open(Folder.READ_ONLY);
long startTime = System.currentTimeMillis();
List<EmailMessage> messages = getEmailMessages(inbox, start, max, session);
if (log.isDebugEnabled()) {
long elapsedTime = System.currentTimeMillis() - startTime;
int messagesToDisplayCount = messages.size();
log.debug(
"Finished looking up email messages. Inbox size: "
+ inbox.getMessageCount()
+ " Unread message count: "
+ inbox.getUnreadMessageCount()
+ " Total elapsed time: "
+ elapsedTime
+ "ms "
+ " Time per message in inbox: "
+ (inbox.getMessageCount() == 0 ? 0 : (elapsedTime / inbox.getMessageCount()))
+ "ms"
+ " Time per displayed message: "
+ (messagesToDisplayCount == 0 ? 0 : (elapsedTime / messagesToDisplayCount))
+ "ms");
}
IEmailLinkService linkService =
linkServiceRegistry.getEmailLinkService(config.getLinkServiceKey());
String inboxUrl = null;
if (linkService != null) {
inboxUrl = linkService.getInboxUrl(config);
}
// Initialize account information with information retrieved from inbox
summary =
new AccountSummary(
inboxUrl,
messages,
inbox.getUnreadMessageCount(),
inbox.getMessageCount(),
start,
max,
isDeleteSupported(inbox),
getQuota(inbox));
if (log.isDebugEnabled()) {
log.debug("Successfully retrieved email AccountSummary");
}
return summary;
} catch (MailAuthenticationException mae) {
// We used just to allow this exception to percolate up the chain,
// but we learned that the entire stack trace gets written to
// Catalina.out (by 3rd party code). Since this is a common
// occurrence, it causes space issues.
return new AccountSummary(mae);
} catch (MessagingException me) {
log.error("Exception encountered while retrieving account info", me);
throw new EmailPreviewException(me);
} catch (IOException e) {
log.error("Exception encountered while retrieving account info", e);
throw new EmailPreviewException(e);
} catch (ScanException e) {
log.error("Exception encountered while retrieving account info", e);
throw new EmailPreviewException(e);
} catch (PolicyException e) {
log.error("Exception encountered while retrieving account info", e);
throw new EmailPreviewException(e);
} finally {
if (inbox != null) {
try {
inbox.close(false);
} catch (Exception e) {
log.warn("Can't close correctly javamail inbox connection");
}
try {
inbox.getStore().close();
} catch (Exception e) {
log.warn("Can't close correctly javamail store connection");
}
}
}
}
private Session openMailSession(MailStoreConfiguration config, Authenticator auth) {
// Assertions.
if (config == null) {
String msg = "Argument 'config' cannot be null";
throw new IllegalArgumentException(msg);
}
if (auth == null) {
String msg = "Argument 'auth' cannot be null";
throw new IllegalArgumentException(msg);
}
// Initialize connection properties
Properties mailProperties = new Properties();
mailProperties.put("mail.store.protocol", config.getProtocol());
mailProperties.put("mail.host", config.getHost());
mailProperties.put("mail.port", config.getPort());
mailProperties.put("mail.debug", debug ? "true" : "false");
String protocolPropertyPrefix = "mail." + config.getProtocol() + ".";
// Set connection timeout property
int connectionTimeout = config.getConnectionTimeout();
if (connectionTimeout >= 0) {
mailProperties.put(protocolPropertyPrefix + "connectiontimeout", connectionTimeout);
}
// Set timeout property
int timeout = config.getTimeout();
if (timeout >= 0) {
mailProperties.put(protocolPropertyPrefix + "timeout", timeout);
}
// add each additional property
for (Map.Entry<String, String> property : config.getJavaMailProperties().entrySet()) {
mailProperties.put(property.getKey(), property.getValue());
}
// Connect/authenticate to the configured store
return Session.getInstance(mailProperties, auth);
}
@Override
public EmailMessage getMessage(MailStoreConfiguration config, String messageId) {
Authenticator auth = credentialsProvider.getAuthenticator();
Folder inbox = null;
try {
int mode = config.getMarkMessagesAsRead() ? Folder.READ_WRITE : Folder.READ_ONLY;
// Retrieve user's inbox
Session session = openMailSession(config, auth);
inbox = getUserInbox(session, config.getInboxFolderName());
inbox.open(mode);
Message message;
if (inbox instanceof UIDFolder) {
message = ((UIDFolder) inbox).getMessageByUID(Long.parseLong(messageId));
} else {
message = inbox.getMessage(Integer.parseInt(messageId));
}
boolean unread = !message.isSet(Flags.Flag.SEEN);
if (config.getMarkMessagesAsRead()) {
message.setFlag(Flag.SEEN, true);
}
EmailMessage emailMessage = wrapMessage(message, true, session);
if (!config.getMarkMessagesAsRead()) {
// NOTE: This is more than a little bit annoying. Apparently
// the mere act of accessing the body content of a message in
// Javamail flags the in-memory representation of that message
// as SEEN. It does *nothing* to the mail server (the message
// is still unread in the SOR), but it wreaks havoc on local
// functions that key off that value and expect it to be
// accurate. We're obligated, therefore, to restore the value
// to what it was before the call to wrapMessage().
emailMessage.setUnread(unread);
}
return emailMessage;
} catch (MessagingException e) {
log.error("Messaging exception while retrieving individual message", e);
} catch (IOException e) {
log.error("IO exception while retrieving individual message", e);
} catch (ScanException e) {
log.error("AntiSamy scanning exception while retrieving individual message", e);
} catch (PolicyException e) {
log.error("AntiSamy policy exception while retrieving individual message", e);
} finally {
if (inbox != null) {
try {
inbox.close(false);
} catch (Exception e) {
log.warn("Can't close correctly javamail inbox connection");
}
try {
inbox.getStore().close();
} catch (Exception e) {
log.warn("Can't close correctly javamail store connection");
}
}
}
return null;
}
private Folder getUserInbox(Session session, String folderName) throws MessagingException {
// Assertions.
if (session == null) {
String msg = "Argument 'session' cannot be null";
throw new IllegalArgumentException(msg);
}
try {
Store store = session.getStore();
store.connect();
if (log.isDebugEnabled()) {
log.debug("Mail store connection established to get user inbox");
}
// Retrieve user's inbox folder
Folder root = store.getDefaultFolder();
Folder inboxFolder = root.getFolder(folderName);
return inboxFolder;
} catch (AuthenticationFailedException e) {
throw new MailAuthenticationException(e);
}
}
private EmailMessage wrapMessage(Message msg, boolean populateContent, Session session)
throws MessagingException, IOException, ScanException, PolicyException {
// Prepare subject
String subject = msg.getSubject();
if (!StringUtils.isBlank(subject)) {
AntiSamy as = new AntiSamy();
CleanResults cr = as.scan(subject, policy);
subject = cr.getCleanHTML();
}
// Prepare content if requested
EmailMessageContent msgContent = null; // default...
if (populateContent) {
// Defend against the dreaded: "Unable to load BODYSTRUCTURE"
try {
msgContent = getMessageContent(msg.getContent(), msg.getContentType());
} catch (MessagingException me) {
// We are unable to read digitally-signed messages (perhaps
// others?) in the API-standard way; we have to use a work around.
// See: http://www.oracle.com/technetwork/java/faq-135477.html#imapserverbug
// Logging as DEBUG because this behavior is known & expected.
log.debug("Difficulty reading a message (digitally signed?). Attempting workaround...");
try {
MimeMessage mm = (MimeMessage) msg;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
mm.writeTo(bos);
bos.close();
SharedByteArrayInputStream bis = new SharedByteArrayInputStream(bos.toByteArray());
MimeMessage copy = new MimeMessage(session, bis);
bis.close();
msgContent = getMessageContent(copy.getContent(), copy.getContentType());
} catch (Throwable t) {
log.error("Failed to read message body", t);
msgContent =
new EmailMessageContent("UNABLE TO READ MESSAGE BODY: " + t.getMessage(), false);
}
}
// Sanitize with AntiSamy
String content = msgContent.getContentString();
if (!StringUtils.isBlank(content)) {
AntiSamy as = new AntiSamy();
CleanResults cr = as.scan(content, policy);
content = cr.getCleanHTML();
}
msgContent.setContentString(content);
}
int messageNumber = msg.getMessageNumber();
// Prepare the UID if present
String uid = null; // default
if (msg.getFolder() instanceof UIDFolder) {
uid = Long.toString(((UIDFolder) msg.getFolder()).getUID(msg));
}
Address[] addr = msg.getFrom();
String sender = getFormattedAddresses(addr);
Date sentDate = msg.getSentDate();
boolean unread = !msg.isSet(Flag.SEEN);
boolean answered = msg.isSet(Flag.ANSWERED);
boolean deleted = msg.isSet(Flag.DELETED);
// Defend against the dreaded: "Unable to load BODYSTRUCTURE"
boolean multipart = false; // sensible default;
String contentType = null; // sensible default
try {
multipart = msg.getContentType().toLowerCase().startsWith(CONTENT_TYPE_ATTACHMENTS_PATTERN);
contentType = msg.getContentType();
} catch (MessagingException me) {
// Message was digitally signed and we are unable to read it;
// logging as DEBUG because this issue is known/expected, and
// because the user's experience is in no way affected (at this point)
log.debug(
"Message content unavailable (digitally signed?); "
+ "message will appear in the preview table correctly, "
+ "but the body will not be viewable");
log.trace(me.getMessage(), me);
}
String to = getTo(msg);
String cc = getCc(msg);
String bcc = getBcc(msg);
return new EmailMessage(
messageNumber,
uid,
sender,
subject,
sentDate,
unread,
answered,
deleted,
multipart,
contentType,
msgContent,
to,
cc,
bcc);
}
/*
* Implementation
*/
private List<EmailMessage> getEmailMessages(
Folder mailFolder, int pageStart, int messageCount, Session session)
throws MessagingException, IOException, ScanException, PolicyException {
int totalMessageCount = mailFolder.getMessageCount();
int start = Math.max(1, totalMessageCount - pageStart - (messageCount - 1));
int end = Math.max(totalMessageCount - pageStart, 1);
Message[] messages =
totalMessageCount != 0 ? mailFolder.getMessages(start, end) : new Message[0];
long startTime = System.currentTimeMillis();
// Fetch only necessary headers for each message
FetchProfile profile = new FetchProfile();
profile.add(FetchProfile.Item.ENVELOPE);
profile.add(FetchProfile.Item.FLAGS);
profile.add(FetchProfile.Item.CONTENT_INFO);
if (mailFolder instanceof UIDFolder) {
profile.add(UIDFolder.FetchProfileItem.UID);
}
mailFolder.fetch(messages, profile);
if (log.isDebugEnabled()) {
log.debug(
"Time elapsed while fetching message headers; {}ms",
System.currentTimeMillis() - startTime);
}
List<EmailMessage> emails = new LinkedList<EmailMessage>();
for (Message currentMessage : messages) {
EmailMessage emailMessage = wrapMessage(currentMessage, false, session);
emails.add(emailMessage);
}
Collections.reverse(emails);
return emails;
}
private EmailMessageContent getMessageContent(Object content, String mimeType)
throws IOException, MessagingException {
// if this content item is a String, simply return it.
if (content instanceof String) {
return new EmailMessageContent((String) content, isHtml(mimeType));
} else if (content instanceof MimeMultipart) {
Multipart m = (Multipart) content;
int parts = m.getCount();
// iterate backwards through the parts list
for (int i = parts - 1; i >= 0; i--) {
EmailMessageContent result = null;
BodyPart part = m.getBodyPart(i);
Object partContent = part.getContent();
String contentType = part.getContentType();
boolean isHtml = isHtml(contentType);
log.debug(
"Examining Multipart "
+ i
+ " with type "
+ contentType
+ " and class "
+ partContent.getClass());
if (partContent instanceof String) {
result = new EmailMessageContent((String) partContent, isHtml);
} else if (partContent instanceof InputStream && (contentType.startsWith("text/html"))) {
StringWriter writer = new StringWriter();
IOUtils.copy((InputStream) partContent, writer);
result = new EmailMessageContent(writer.toString(), isHtml);
} else if (partContent instanceof MimeMultipart) {
result = getMessageContent(partContent, contentType);
}
if (result != null) {
return result;
}
}
}
return null;
}
/**
* Determine if the supplied MIME type represents HTML content. This implementation assumes that
* the inclusion of the string "text/html" in a mime-type indicates HTML content.
*
* @param mimeType
* @return
*/
private boolean isHtml(String mimeType) {
// if the mime-type is null, assume the content is not HTML
if (mimeType == null) {
return false;
}
// otherwise, check for the presence of the string "text/html"
mimeType = mimeType.trim().toLowerCase();
if (mimeType.contains("text/html")) {
return true;
}
return false;
}
@Override
public boolean deleteMessages(MailStoreConfiguration config, String[] uuids) {
Authenticator auth = credentialsProvider.getAuthenticator();
Folder inbox = null;
try {
// Retrieve user's inbox
Session session = openMailSession(config, auth);
inbox = getUserInbox(session, config.getInboxFolderName());
// Verify that we can even perform this operation
if (!(inbox instanceof UIDFolder)) {
String msg = "Delete feature is supported only for UIDFolder instances";
throw new UnsupportedOperationException(msg);
}
inbox.open(Folder.READ_WRITE);
Message[] msgs = ((UIDFolder) inbox).getMessagesByUID(getMessageUidsAsLong(uuids));
inbox.setFlags(msgs, new Flags(Flag.DELETED), true);
return true; // Indicate success
} catch (MessagingException e) {
log.error("Messaging exception while deleting messages", e);
} finally {
if (inbox != null) {
try {
inbox.close(false);
} catch (Exception e) {
log.warn("Can't close correctly javamail inbox connection");
}
try {
inbox.getStore().close();
} catch (Exception e) {
log.warn("Can't close correctly javamail store connection");
}
}
}
return false; // We failed if we reached this point
}
private long[] getMessageUidsAsLong(String[] messageIds) {
long[] ids = new long[messageIds.length];
int i = 0;
for (String id : messageIds) {
ids[i++] = Long.parseLong(id);
}
return ids;
}
@Override
public boolean setMessageReadStatus(MailStoreConfiguration config, String[] uuids, boolean read) {
Authenticator auth = credentialsProvider.getAuthenticator();
Folder inbox = null;
try {
// Retrieve user's inbox
Session session = openMailSession(config, auth);
inbox = getUserInbox(session, config.getInboxFolderName());
// Verify that we can even perform this operation log info if it isn't capable of operation
if (!(inbox instanceof UIDFolder)) {
String msg = "Toggle unread feature is supported only for UIDFolder instances";
log.info(msg);
return false;
}
inbox.open(Folder.READ_WRITE);
Message[] msgs = ((UIDFolder) inbox).getMessagesByUID(getMessageUidsAsLong(uuids));
inbox.setFlags(msgs, new Flags(Flag.SEEN), read);
return true; // Indicate success
} catch (MessagingException e) {
log.error("Messaging exception while deleting messages", e);
} finally {
if (inbox != null) {
try {
inbox.close(false);
} catch (Exception e) {
log.warn("Can't close correctly javamail inbox connection");
}
try {
inbox.getStore().close();
} catch (Exception e) {
log.warn("Can't close correctly javamail store connection");
}
}
}
return false; // We failed if we reached this point
}
@Override
public List<Folder> getAllUserInboxFolders(MailStoreConfiguration config) {
Authenticator auth = credentialsProvider.getAuthenticator();
Store store = null;
try {
Session session = openMailSession(config, auth);
// Assertions.
if (session == null) {
String msg = "Argument 'session' cannot be null";
throw new IllegalArgumentException(msg);
}
store = session.getStore();
store.connect();
if (log.isDebugEnabled()) {
log.debug("Mail store connection established to get all user inbox folders");
}
// Retrieve user's inbox folder
return Arrays.asList(store.getDefaultFolder().list("*"));
} catch (Exception e) {
log.error("Can't get all user Inbox folders");
return null;
} finally {
if (store != null) {
try {
store.close();
} catch (Exception e) {
log.warn("Can't close correctly javamail store connection");
}
}
}
}
private boolean isDeleteSupported(Folder f) {
return f instanceof UIDFolder;
}
private EmailQuota getQuota(Folder folder) {
if (!(folder instanceof IMAPFolder)) {
return null;
}
try {
// Make sure the account is activated and contains messages
if (folder.exists() && folder.getMessageCount() > 0) {
Quota[] quotas = ((IMAPFolder) folder).getQuota();
for (Quota quota : quotas) {
for (Quota.Resource resource : quota.resources) {
if (resource.name.equals("STORAGE")) {
return new EmailQuota(resource.limit, resource.usage);
}
}
}
}
} catch (MessagingException e) {
log.error("Failed to connect or get quota for mail user ");
}
return null;
}
private String getTo(Message message) throws MessagingException {
Address[] toRecipients = message.getRecipients(RecipientType.TO);
return getFormattedAddresses(toRecipients);
}
private String getCc(Message message) throws MessagingException {
Address[] ccRecipients = message.getRecipients(RecipientType.CC);
return getFormattedAddresses(ccRecipients);
}
private String getBcc(Message message) throws MessagingException {
Address[] bccRecipients = message.getRecipients(RecipientType.BCC);
return getFormattedAddresses(bccRecipients);
}
private String getFormattedAddresses(Address[] addresses) {
List<String> recipientsList = new ArrayList<String>();
String receiver = null;
if (addresses != null && addresses.length != 0) {
for (Address adress : addresses) {
if (INTERNET_ADDRESS_TYPE.equals(adress.getType())) {
InternetAddress inet = (InternetAddress) adress;
receiver = inet.toUnicodeString();
} else {
receiver = adress.toString();
}
recipientsList.add(receiver);
}
}
return StringUtils.join(recipientsList, "; ").replaceAll("<", "<").replaceAll(">", ">");
}
}